PlayerInfo高频同步解决方案
描述
需求目的
分析一个常见的需求: “在 1P
客户端显示 3P
的 Transform
”。
显然,在客户端存在 3P
的 Pawn
时,可以直接取 Pawn
的 Transform
;但出于性能考虑,会进行各种 AOI
机制,在较远距离时客户端会将 3P
的 Pawn
裁剪掉,只留下 PlayerState
(或者某个不被剪裁的数据 Channel
) 用于同步。
一个直观的想法是将 Transform
直接通过对应的 PlayerState
属性同步给所有客户端;但出于性能考虑,对于同步一般会开启 PushModel
;这种高频字段会频繁将 PlayerState
对应 ActorChannel
给 MarkDirty
,导致 PushModel
功能基本失效,频繁进行同步的 Diff
等大开销的操作;
所以需要一个机制对这种情况进行优化。
核心思路
对于 DS
,创建一个 Channel
专门用于同步 Player
的高频变化信息,如 Location
、Rotation
等;
对于同步的信息,进行适当的同步降频(不需要每帧同步)、字节压缩(舍弃部分精度,精确到 float
没有意义);
同时为了保证 Client
的信息相对正确(同步降频会导致 Location
不连续),在 1P
Client
进行信息的预测插值;
实现
Replicator
创建一个 Actor
- PlayerSyncInfoReplicator
专门用于打包所有 Player
高频 Info
(这里特指 Location
、Rotation
) 进行数据同步,在合适的地方进行初始化(比如 ReplicationGraph
初始化后);
Replicator
包括一个 FPlayerSyncInfoContainer
结构体用于打包数据、进行数据的收集、自定义序列化;
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void APlayerSyncInfoReplicator::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps);
FDoRepLifetimeParams Params; DOREPLIFETIME_WITH_PARAMS(APlayerSyncInfoReplicator, PlayerSyncInfoContainer, Params); }
void APlayerSyncInfoReplicator::PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker) { PlayerSyncInfoContainer.PreReplication(); }
|
Define Data & Byte Compression
对于每一个 Player
,需要收集 FSyncInfo
,这里记录了一个 SyncLocation : FVector_NetQuantize
与 Yaw
;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| USTRUCT() struct FSyncInfo { GENERATED_BODY()
FSyncInfo() = default; FSyncInfo(const FVector_NetQuantize& InSyncLocation) : SyncLocation(InSyncLocation) { } bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);
public: FVector_NetQuantize SyncLocation; float Yaw; };
template<> struct TStructOpsTypeTraits< FSyncInfo > : public TStructOpsTypeTraitsBase2< FSyncInfo > { enum { WithNetSerializer = true, WithNetSharedSerialization = true, }; };
|
FVector_NetQuantize
是一个自定义的类型,进行了对 FVector
的数据压缩。
由于表达一个 float
所需要的 Bit
比较多,但大部分情况不需要同步到 float
这么高精度的数据),比如一个有限范围的大世界, Location
显然不需要那么高的精度表示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| USTRUCT() struct FVector_NetQuantize : public FVector { GENERATED_USTRUCT_BODY()
FORCEINLINE FVector_NetQuantize() {}
explicit FORCEINLINE FVector_NetQuantize(EForceInit E) : FVector(E) FORCEINLINE FVector_NetQuantize( float InX, float InY, float InZ ) : FVector(InX, InY, InZ) {} FORCEINLINE FVector_NetQuantize( const FVector &InVec ) { FVector::operator=(InVec); }
bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess) { bOutSuccess = SerializePackedVector<1, 20>(*this, Ar); return true; } };
template<> struct TStructOpsTypeTraits< FVector_NetQuantize > : public TStructOpsTypeTraitsBase2< FVector_NetQuantize > { enum { WithNetSerializer = true, WithNetSharedSerialization = true, }; };
|
这里除了将 FVector
压缩为一个 20bit
的 FVector_NetQuantize
,还可以对 Yaw
这一旋转角度进行压缩:
- 定义一个
uint8
的 ByteYaw
,存储 Yaw
的压缩版本,通过 FRotator::CompressAxisToByte
将其压缩为一个字节;
- 新增一个标志位
NotZero
判断 ByteRaw
是否是 0
(不需要同步 Yaw
时候,该值就为 0
,可能清空较多),如果为 0
,则只需要存储 NotZero
的值(1位),而不是整个 ByteYaw
(8位);
对于 FSyncInfo
,自定义其 NetSerialize
逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| bool FSyncInfo::NetSerialize(FArchive& Ar, UPackageMap* Map, bool& bOutSuccess) { SyncLocation.NetSerialize(Ar, Map, bOutSuccess);
uint8 ByteYaw = 0; if ( Ar.IsSaving() ) ByteYaw = FRotator::CompressAxisToByte(Yaw);
uint8 NotZero = ByteYaw != 0; Ar.SerializeBits( &NotZero, 1 ); if ( NotZero ) Ar << ByteYaw; else ByteYaw = 0;
if( Ar.IsLoading() ) { Yaw = FRotator::DecompressAxisFromByte(ByteYaw); }
return true; }
|
这里就定义完毕了 FSyncInfo
,显然需要一个 FSyncInfo
对 Player
UID
的映射,打包成一个结构体 FPlayerSyncInfo
。
一般使用 uint64
来保存 UID
,可以简单的用额外的一个 bit
判断 UID
是否 > MAX_uint32
,用来节省一些流量(也可以将 uint64
分为 4 个 16bit
,用额外的 2bit
来判断在哪一段范围)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| USTRUCT() struct FPlayerSyncInfo { GENERATED_BODY() FPlayerSyncInfo() = default; FPlayerSyncInfo(uint64 UID, const FSyncInfo& SyncInfo) : UID(UID), SyncInfo(SyncInfo) {}; bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);
public: uint64 UID; FSyncInfo SyncInfo; };
template<> struct TStructOpsTypeTraits< FPlayerSyncInfo > : public TStructOpsTypeTraitsBase2< FPlayerSyncInfo > { enum { WithNetSerializer = true, WithNetSharedSerialization = true, }; };
bool FPlayerSyncInfo::NetSerialize(FArchive& Ar, UPackageMap* Map, bool& bOutSuccess) { SyncInfo.NetSerialize(Ar, Map, bOutSuccess);
uint8 UID_IsHigh = UID > MAX_uint32; Ar.SerializeBits(&IsHigh, 1)
if (UID_IsHigh) { Ar << UID; } else { uint32 UID_LowBits = (uint32)UID; Ar << UID_LowBits; UID = (uint64)UID_LowBits; }
return true; }
|
Collect Data
FPlayerSyncInfoContainer
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| USTRUCT() struct FPlayerSyncInfoContainer { GENERATED_BODY()
public: FPlayerSyncInfoContainer() {};
bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess); void PreReplication(); #endif
bool operator==(const FPlayerSyncInfoContainer& Other) const { return FrameCounter == Other.FrameCounter; } bool operator!=(const FPlayerSyncInfoContainer& Other) const { return !(*this == Other); } private: void CollectReplicateData(); private: uint64 FrameCounter = 0; uint32 PastFrameCounter = 1; bool bHasCollectDataThisFrame = false; uint32 InfoCount = 0; TSharedPtr <FNetBitWriter> WriterPtr; TWeakObjectPtr <class APlayerSyncInfoReplicator> Replicator; };
template<> struct TStructOpsTypeTraits<FPlayerSyncInfoContainer> : public TStructOpsTypeTraitsBase2<FPlayerSyncInfoContainer> { enum { WithNetSerializer = true, WithIdenticalViaEquality = true, WithCopy = false, }; };
|
在 PreReplication
中进行 CollectReplicateData
;
通过 CVarPlayerSyncInfoReplicateFrameInternal
来控制同步的频率,记录 PastFrameCounter
用于记录过去了多少帧,用于后续判断;
1 2 3 4 5 6 7 8 9 10
| void FPlayerSyncInfoContainer::PreReplication() { if (FrameCounter == GFrameCounter) return; PastFrameCounter = FMath::Clamp<uint32>(GFrameCounter - FrameCounter, 1, CVarPlayerSyncInfoReplicateFrameInternal + 1); FrameCounter = GFrameCounter; bHasCollectDataThisFrame = false;
CollectReplicateData(); }
|
在 CollectReplicate
中进行数据收集与统计,将收集到的数据字节流写入 Writer
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| void FPlayerSyncInfoContainer::CollectReplicateData() { if (!Replicator.IsValid()) return; if (bHasCollectDataThisFrame) return; bHasCollectDataThisFrame = true;
if (!WriterPtr.IsValid()) { WriterPtr = MakeShared<FNetBitWriter>(); WriterPtr->SetAllowResize(true); } else { WriterPtr.Get()->Reset(); InfoCount = 0; }
FNetBitWriter* Writer = WriterPtr.Get(); bool bOutSuccess = true;
const auto SerializePlayerSyncInfo = [&](const auto& PlayerState) { if (!PlayerState.IsValid()) return; uint64 UID = PlayerState->GetUID(); FVector Location = INVALID_LOCATION float Yaw = 0; if (APawn* PlayerPawn = PlayerState->GetPawn(); IsValid(PlayerPawn)) { const FTransform& Transform = PlayerPawn->GetTransform(); Location = Transform.GetLocation(); Yaw = Transform.Rotator().Yaw; }
FVector_NetQuantize SyncLocation(Location.X, Location.Y, Location.Z); FPlayerSyncInfo PlayerSyncInfo(UID, {SyncLocation, Yaw}); PlayerSyncInfo.NetSerialize(*Writer, nullptr, bOutSuccess); InfoCount++; };
for (const auto& PlayerState : PlayerArray) { SerializePlayerSyncInfo(PlayerState); } }
|
Serialize
对于每一个 Player
的 Connection
,判断其是否满足同步帧数限制,每次 NetSerialize
,将 ConnectionDriver->NextReplicateFrameCount -= PlayerSyncInfoContainer.PastFameCounter
,若 <= 0
则重置其 NextReplicateFrameCount
为 CVarPlayerSyncInfoReplicateFrameInternal
,并认为本次需要对该 Connection
进行同步;若需要同步,将 Writer
中的数据进行序列化;
在反序列化时,找到对应的 Player
,将数据设置到其 PlayerState
中;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| bool FPlayerSyncInfoContainer::NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess) { if (Ar.IsSaving()) { auto PackageMapClient = Cast<UPackageMapClient>(Map); auto Connection = PackageMapClient->GetConnection(); auto ConnectionDriver = Connection->GetReplicationConnectionDriver(); bool bNeedReplicate = false; if (ConnectionDriver->NextReplicateFrameCount <= 0) { bNeedReplicate = true; ConnectionDriver->NextReplicateFrameCount = CVarPlayerSyncInfoReplicateFrameInternal }
if (bNeedReplicate) { Ar.SerializeIntPacked(InfoCount); Ar.SerializeBits(WriterPtr->GetData(), WriterPtr->GetNumBits()); } else { uint32 Count = 0; Ar.SerializeIntPacked(Count); } bOutSuccess = true; } else { uint32 Count = 0; Ar.SerializeIntPacked(Count); if (Count == 0) return true; const uint64 FrameNum = ++GDSPlayerSyncInfoReplicateFrameNum; for (int Index = 0; Index < Count; Index++) { FPlayerSyncInfo PlayerSyncInfo; PlayerSyncInfo.NetSerialize( Ar, Map, bOutSuccess );
uint64 UID = PlayerSyncInfo.UID; if (auto PlayerState = GET_PLAYERSTATE_BY_UID(UID)) { PlayerState->SetPlayerSyncInfo(PlayerSyncInfo); } } }
return true; }
|
Client Predict
对于 Client
,会间隔收到 3P
的 PlayerSyncInfo
,同时在收包的时候记录了帧号。
对于比如 Location
这样的数据,显然需要保证其连续性。
于是可以根据这些信息做一个简单的预测:
- 在
Client
存在 Pawn
时,直接使用 Pawn
的位置,并且强制更新预测与期望;
- 在收到一个新包
CurrentBack
时,使用 PrePack -> CurrentBack
的 LocationDiff / PreFrameInternal
计算出一个新的预测速度 PredictCalcDeltaSyncLocationVelocity
,同时可以根据实际情况 * SpeedFactor
来进行适当修改;
- 每次
GetSyncLocation
时尝试更新预测信息,在当前帧 CurrentFrame
,利用 PredictCalcDeltaSyncLocationVelocity * FrameInternal
来外推当前所在 Location
,这里的 FrameInternal
是从 上一次预测帧 LastPredictFrame
开始经过的帧数,预测完毕更新 LastPredictFrame
;
- 如果当前位置和预测期望位置太远,则直接设置为期望位置,不进行插值;
flowchart LR
A(PrePack)
B(CurrentBack)
C(NextBack)
Last([LastPredictFrame])
Now([CurrentFrame])
A---->|PreFrameInternal| B
B-->|Internal|Last
Last-->|Internal|Now
Now-..->C
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| void APlayerState::SetSyncLocation(FVector InSyncLocation, int32 CurrentFrameNum) { bool bEnableSet = EnableSetSyncLocation(); if (bEnableSet) { int64 DeltaFrameCount = UKismetSystemLibrary::GetFrameCount() - SyncLocationFrameCount; float SpeedUpFactor = 1.05f; PredictCalcDeltaSyncLocationVelocity = DeltaFrameCount <= 0 ? 0.0f : FVector::Dist(InSyncLocation, ActorSyncLocation) / DeltaFrameCount * SpeedUpFactor;
ActorSyncLocation = InSyncLocation; } else { PredictCalcDeltaSyncLocationVelocity = 0.0f; } SyncLocationUpdateFrameNum = CurrentFrameNum; SyncLocationFrameCount = UKismetSystemLibrary::GetFrameCount(); }
bool APlayerState::EnableSetSyncLocation() const { return true; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| FVector APlayerState::GetSyncLocation() { if (APawn* Pawn = GetPawn(); IsValid(Pawn)) { FVector Location = Pawn->GetTransform().GetLocation(); ActorSyncLocation.Set(Loc.X, Loc.Y, Loc.Z); UpdatePredictSyncLocation(ActorSyncLocation, true); return ActorSyncLocation; } if (GDSPlayerSyncInfoReplicateFrameNum == SyncLocationUpdateFrameNum && SyncLocationUpdateFrameNum != 0) { UpdatePredictSyncLocation(ActorSyncLocation); return PredictSyncLocation; }
return InvalidSyncLocation; }
void APlayerState::UpdatePredictSyncLocation(FVector InLocation, bool bForce) { int64 CurrentFrame = UKismetSystemLibrary::GetFrameCount(); if (bForce == true) { LastPredictCalcLocationFrameCount = CurrentFrame; PredictSyncLocation = InLocation; PredictCalcDeltaSyncLocationVelocity = 0.0f; return; } int64 DeltaFrame = CurrentFrame - LastPredictCalcLocationFrameCount; if (DeltaFrame <= 0) return; if ( FMath::IsNearlyEqual(PredictCalcDeltaSyncLocationVelocity, 0.0f) || FVector::DistSquared(PredictSyncLocation, InLocation) >= PredictSyncLocationDistanceLimitSquared) { PredictSyncLocation = InLocation; } else { PredictSyncLocation += DeltaFrame * (InLocation - PredictSyncLocation).GetSafeNormal() * PredictCalcDeltaSyncLocationVelocity; } LastPredictCalcLocationFrameCount = CurrentFrame; }
|
这样,就可以得到一个相对丝滑的 Location
信息。